Repository navigation
perf(kernel): import pyarrow on the first result instead of in connect() (stacked on #1049) - #1053
vuanhphung wants to merge 14 commits into
Conversation
client.py and result_set.py imported pandas at module load, so the first connect() in a process spent ~165 ms importing it under the import lock while every other connecting thread waited. pandas is only used when an Arrow table is converted to rows, so import it there. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
…sing Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
4e92967 to
c2432f7
Compare
There was a problem hiding this comment.
Verdict: 1 Low
Looks good: one low-severity concern. The lazy-import design holds up, test patch points aren't broken by moving the kernel imports inside functions, and the subprocess tests use distinct exit codes so a crash can't pass by accident. The concern is that on a broken pyarrow install, the modules using the new stand-in (client, utils, result_set) and the Thrift/SEA modules that still use try/except disagree on whether pyarrow is available.
The kernel backend re-raises a missing databricks_sql_kernel as an ImportError, so look for the missing extra along the exception chain. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
c5a143b to
9ef8be2
Compare
There was a problem hiding this comment.
Verdict: 1 Low
Looks good, with one low test-robustness issue. The _LazyPyarrow stand-in keeps pyarrow is None working, pyarrow.ipc and pyarrow.types resolve after import pyarrow, and I found no test that patches the KernelResultSet or bind_tspark_params names the PR moved, so moving them breaks nothing.
… time Build the test table from IPC bytes, since constructing it from Python lists makes pyarrow import pandas itself. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
9ef8be2 to
e31f637
Compare
Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
ce76c77 to
f77297c
Compare
There was a problem hiding this comment.
Verdict: 1 Low
Looks good. There is one low-severity concern: the lazy stand-in caches attributes, so later patches to the real module don't reach these three modules. The rest checks out: the find_spec gating keeps the pyarrow is None semantics, all three modules use from __future__ import annotations so annotations don't trigger the import, and the subprocess tests use the sentinel exit codes the repo prefers.
client, utils and result_set kept the stand-in as their pyarrow global after the first result, so every pyarrow.X on the query path went through a ModuleType subclass's slower attribute lookup. The first use now replaces the stand-in with the real module in each module that registered its globals. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good, with one low concern: when pyarrow is installed but can't be imported, the import is retried on every attribute access. The lazy stand-in keeps pyarrow is None working, rebinds the module globals after first use, and is covered by sentinel-exit subprocess tests. The kernel client's deferred KernelResultSet and bind_tspark_params imports run inside the existing try/except blocks, and no tests patch the moved names.
When pyarrow is installed but can't be imported, every attribute read on the stand-in retried the import, rerunning pyarrow's and numpy's partial initialization. The first ImportError is now kept and later reads raise from it, as the eager import ran it once. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
The stand-in copied each resolved attribute onto itself, so a later patch of pyarrow (mock.patch, monkeypatch, shims) didn't reach code reading through it. It now keeps no copies and always delegates to the imported module; the hot path reads the real module directly since the rebinding. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
What type of PR is this?
Description
Stacked on #1049 (lazy pandas). Its two commits show in this diff until it merges, and without it pandas would still import numpy in
connect().A kernel
connect()imports pyarrow, and with it numpy.client.py,utils.pyandresult_set.pyimport pyarrow at module load as an optional dependency. The kernel client imports its result set and type mapping, which need pyarrow, at module load too. Besides the import itself (~70 ms here), numpy starts OpenBLAS's thread pool: one thread per core, busy-waiting for a while after start. A bareimport numpyburns 0.33 CPU-seconds in the following idle second on 8 cores (0.00 withOPENBLAS_NUM_THREADS=1). During a 200-thread connect burst that spin was half the process's CPU and took ~65 ms ofconnect()p50 (measured diagnostically withOPENBLAS_NUM_THREADS=1). With several processes starting at once it competes with every connection being opened.The three modules now get
pyarrowfromoptional_pyarrow(). It returns the real module if pyarrow is already imported,Noneif it isn't installed, and otherwise a stand-in that imports pyarrow on first attribute access. That first access also rebindspyarrowin those modules to the real module, so reads on the query path are plain module attribute lookups (through the stand-in a read costs 63 ns instead of 9 ns, 12 reads per query). Installation is decided withimportlib.util.find_spec, so everypyarrow is Nonecheck, and the warningclient.pylogs when pyarrow is missing, behave as before. The module attributes keep their names, and type checkers still see a plainimport pyarrow. The kernel client imports its result set and type mapping at the first result and the first bound parameter. The Thrift and SEA backends are unchanged and still import pyarrow when they load. A kernel connection now imports pyarrow and numpy at its firstexecute(), and pandas (#1049) at its first row conversion. One edge case changes: pyarrow installed but failing to import (a broken install, e.g. a numpy ABI mismatch). Before, every module treated it as absent. Nowclient,utilsandresult_settreat it as available, and the first attribute use raises anAttributeErrorthat carries theImportError, sohasattrandgetattr(..., default)still work. The Thrift and SEA backends still import it when they load and treat it as missing, so the Thrift result format is unchanged. In that case the "pyarrow is not installed" warning isn't logged, and connection telemetry reportsenable_arrow=True. Making those agree would mean importing pyarrow inconnect(), which this change exists to avoid.How is this tested?
New tests, in fresh interpreters with a stand-in kernel wheel: importing
databricks.sql,client,utils,result_setand the kernel client loads none of pyarrow, numpy or pandas.connect(use_kernel=True)doesn't either, and building the first result set does import pyarrow. With pyarrow made unimportable, the three modules'pyarrowisNone. The stand-in resolves to the real classes and hands back the real module once pyarrow is imported. The full unit suite, therealkernelsets and mypy pass.Local mock server adding 20 ms per request, 8 cores, kernel
main, connector #1049 + #1051 with and without this change, fresh processes, interleaved:import databricks.sql.client(#1049 only), median of 5.pycconnect(use_kernel=True)+ 1 query, 3 runs:connect()p50In the single process, the first result arrives at about the same time or a little later, because pyarrow's import moves there. With four processes starting together, the first query finishes earlier, because numpy's spinning threads no longer overlap the connect burst. The c800 open-time row is measured from process launch, using each session's open timestamp, not c800's own
connect()percentiles. c800 starts its threads in a loop, and the import's GIL use delays that loop, so before this change half its threads startedconnect()0.3 s or more late and their timings missed that wait.Steady state, vs #1049 alone (isolated run, c800 4 × 200, 20 ms mock, 60 s, 16 cores, medians of 2 rounds): QPS 5548 → 5582, CPU per query 1.429 → 1.434 ms, each session's open time from process launch p50 1.68 → 1.63 s, all sessions open 1.98 → 1.88 s. Before the rebinding, steady QPS was 2% lower.
Live, on a Reyden warehouse (1 process × 200 threads + 1 query, 8 cores, 5 interleaved runs, a kernel with #453/#455 in both arms),
mainvs. #1049 + #1051 + this PR together:connect()p50 (from a start barrier) 688 → 368 ms, all sessions open 0.80 → 0.43 s, first result p50 0.83 → 0.77 s, medians of runs.Live benchmark (staging Large Reyden warehouse)
TPC-H Q6 on a Large Real-Time warehouse, 800 sessions (4 processes x 200 threads), cold start, each session querying back to back for 120 s, 16 client cores. Isolated A/B: only this change vs #1049 c193711 (this PR is stacked on it), 3 interleaved rounds, nothing else on the warehouse. Measured at 517c4c8. Median (min-max):
QPS within noise; small cold-start connect gain.
Related Tickets & Documents
RE-17633 (connection-creation time at high concurrency).
This pull request and its description were written by Isaac.